<?php

namespace Aws\Api;

use Aws;

/**
 * Validates a schema against a hash of input.
 */
class Validator {
	private $path = [];
	private $errors = [];
	private $constraints = [];

	private static $defaultConstraints = [
		'required' => true,
		'min' => true,
		'max' => false,
		'pattern' => false
	];

	/**
	 * @param array $constraints Associative array of constraints to enforce.
	 *                           Accepts the following keys: "required", "min",
	 *                           "max", and "pattern". If a key is not
	 *                           provided, the constraint will assume false.
	 */
	public function __construct(array $constraints = null) {
		static $assumedFalseValues = [
			'required' => false,
			'min' => false,
			'max' => false,
			'pattern' => false
		];
		$this->constraints = empty($constraints)
			? self::$defaultConstraints
			: $constraints + $assumedFalseValues;
	}

	/**
	 * Validates the given input against the schema.
	 *
	 * @param string $name Operation name
	 * @param Shape $shape Shape to validate
	 * @param array $input Input to validate
	 *
	 * @throws \InvalidArgumentException if the input is invalid.
	 */
	public function validate($name, Shape $shape, array $input) {
		$this->dispatch($shape, $input);

		if($this->errors) {
			$message = sprintf(
				"Found %d error%s while validating the input provided for the "
				."%s operation:\n%s",
				count($this->errors),
				count($this->errors) > 1 ? 's' : '',
				$name,
				implode("\n", $this->errors)
			);
			$this->errors = [];

			throw new \InvalidArgumentException($message);
		}
	}

	private function dispatch(Shape $shape, $value) {
		static $methods = [
			'structure' => 'check_structure',
			'list' => 'check_list',
			'map' => 'check_map',
			'blob' => 'check_blob',
			'boolean' => 'check_boolean',
			'integer' => 'check_numeric',
			'float' => 'check_numeric',
			'long' => 'check_numeric',
			'string' => 'check_string',
			'byte' => 'check_string',
			'char' => 'check_string'
		];

		$type = $shape->getType();
		if(isset($methods[$type])) {
			$this->{$methods[$type]}($shape, $value);
		}
	}

	private function check_structure(StructureShape $shape, $value) {
		$isDocument = (isset($shape['document']) && $shape['document']);
		$isUnion = (isset($shape['union']) && $shape['union']);
		if($isDocument) {
			if(!$this->checkDocumentType($value)) {
				$this->addError("is not a valid document type");
				return;
			}
		} elseif($isUnion) {
			if(!$this->checkUnion($value)) {
				$this->addError("is a union type and must have exactly one non null value");
				return;
			}
		} elseif(!$this->checkAssociativeArray($value)) {
			return;
		}

		if($this->constraints['required'] && $shape['required']) {
			foreach($shape['required'] as $req) {
				if(!isset($value[$req])) {
					$this->path[] = $req;
					$this->addError('is missing and is a required parameter');
					array_pop($this->path);
				}
			}
		}
		if(!$isDocument) {
			foreach($value as $name => $v) {
				if($shape->hasMember($name)) {
					$this->path[] = $name;
					$this->dispatch(
						$shape->getMember($name),
						isset($value[$name]) ? $value[$name] : null
					);
					array_pop($this->path);
				}
			}
		}
	}

	private function check_list(ListShape $shape, $value) {
		if(!is_array($value)) {
			$this->addError('must be an array. Found '
				.Aws\describe_type($value));
			return;
		}

		$this->validateRange($shape, count($value), "list element count");

		$items = $shape->getMember();
		foreach($value as $index => $v) {
			$this->path[] = $index;
			$this->dispatch($items, $v);
			array_pop($this->path);
		}
	}

	private function check_map(MapShape $shape, $value) {
		if(!$this->checkAssociativeArray($value)) {
			return;
		}

		$values = $shape->getValue();
		foreach($value as $key => $v) {
			$this->path[] = $key;
			$this->dispatch($values, $v);
			array_pop($this->path);
		}
	}

	private function check_blob(Shape $shape, $value) {
		static $valid = [
			'string' => true,
			'integer' => true,
			'double' => true,
			'resource' => true
		];

		$type = gettype($value);
		if(!isset($valid[$type])) {
			if($type != 'object' || !method_exists($value, '__toString')) {
				$this->addError('must be an fopen resource, a '
					.'GuzzleHttp\Stream\StreamInterface object, or something '
					.'that can be cast to a string. Found '
					.Aws\describe_type($value));
			}
		}
	}

	private function check_numeric(Shape $shape, $value) {
		if(!is_numeric($value)) {
			$this->addError('must be numeric. Found '
				.Aws\describe_type($value));
			return;
		}

		$this->validateRange($shape, $value, "numeric value");
	}

	private function check_boolean(Shape $shape, $value) {
		if(!is_bool($value)) {
			$this->addError('must be a boolean. Found '
				.Aws\describe_type($value));
		}
	}

	private function check_string(Shape $shape, $value) {
		if($shape['jsonvalue']) {
			if(!self::canJsonEncode($value)) {
				$this->addError('must be a value encodable with \'json_encode\'.'
					.' Found '.Aws\describe_type($value));
			}
			return;
		}

		if(!$this->checkCanString($value)) {
			$this->addError('must be a string or an object that implements '
				.'__toString(). Found '.Aws\describe_type($value));
			return;
		}

		$value = isset($value) ? $value : '';
		$this->validateRange($shape, strlen($value), "string length");

		if($this->constraints['pattern']) {
			$pattern = $shape['pattern'];
			if($pattern && !preg_match("/$pattern/", $value)) {
				$this->addError("Pattern /$pattern/ failed to match '$value'");
			}
		}
	}

	private function validateRange(Shape $shape, $length, $descriptor) {
		if($this->constraints['min']) {
			$min = $shape['min'];
			if($min && $length < $min) {
				$this->addError("expected $descriptor to be >= $min, but "
					."found $descriptor of $length");
			}
		}

		if($this->constraints['max']) {
			$max = $shape['max'];
			if($max && $length > $max) {
				$this->addError("expected $descriptor to be <= $max, but "
					."found $descriptor of $length");
			}
		}
	}

	private function checkArray($arr) {
		return $this->isIndexed($arr) || $this->isAssociative($arr);
	}

	private function isAssociative($arr) {
		return count(array_filter(array_keys($arr), "is_string")) == count($arr);
	}

	private function isIndexed(array $arr) {
		return $arr == array_values($arr);
	}

	private function checkCanString($value) {
		static $valid = [
			'string' => true,
			'integer' => true,
			'double' => true,
			'NULL' => true,
		];

		$type = gettype($value);

		return isset($valid[$type]) ||
			($type == 'object' && method_exists($value, '__toString'));
	}

	private function checkAssociativeArray($value) {
		$isAssociative = false;

		if(is_array($value)) {
			$expectedIndex = 0;
			$key = key($value);

			do {
				$isAssociative = $key !== $expectedIndex++;
				next($value);
				$key = key($value);
			} while(!$isAssociative && null !== $key);
		}

		if(!$isAssociative) {
			$this->addError('must be an associative array. Found '
				.Aws\describe_type($value));
			return false;
		}

		return true;
	}

	private function checkDocumentType($value) {
		if(is_array($value)) {
			$typeOfFirstKey = gettype(key($value));
			foreach($value as $key => $val) {
				if(!$this->checkDocumentType($val) || gettype($key) != $typeOfFirstKey) {
					return false;
				}
			}
			return $this->checkArray($value);
		}
		return is_null($value)
			|| is_numeric($value)
			|| is_string($value)
			|| is_bool($value);
	}

	private function checkUnion($value) {
		if(is_array($value)) {
			$nonNullCount = 0;
			foreach($value as $key => $val) {
				if(!is_null($val) && !(strpos($key, "@") === 0)) {
					$nonNullCount++;
				}
			}
			return $nonNullCount == 1;
		}
		return !is_null($value);
	}

	private function addError($message) {
		$this->errors[] =
			implode('', array_map(function($s) {
				return "[{$s}]";
			}, $this->path))
			.' '
			.$message;
	}

	private function canJsonEncode($data) {
		return !is_resource($data);
	}
}
